iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Modern Web

Ash framework, Elixir 的商業邏輯框架系列 第 2

建立 Ash + Phoenix 專案與相關 task 工具

  • 分享至 

  • xImage
  •  

建立 Ash + Phoenix 專案

本機要先有 PostgreSQL 在跑, 帳號密碼用預設的 postgres / postgres。然後兩個指令:

sh <(curl 'https://ash-hq.org/install/demo_cms?install=phoenix') \
    && cd demo_cms && mix igniter.install ash ash_phoenix \
    ash_postgres ash_authentication ash_authentication_phoenix \
    ash_admin live_debugger --auth-strategy magic_link --setup \
    --yes

這個指令建一個 Phoenix 1.8 專案並裝 Ash 相關套件,
每個套件的 installer 會直接修改專案 (config、router、repo、formatter), 建資料庫並跑 migration

今天我們列出 Ash 提供的 mix task
並與目前正在使用的 phoenix, ecto 的 task 比較

資料庫

Phoenix + Ecto: mix ecto.createmix ecto.migratemix ecto.drop, 還有 alias mix ecto.setupmix ecto.reset

Ash: mix ash.setupmix ash.resetmix ash.tear_down。做的事一樣, 差別是它會去問專案裡每一個 data layer「setup 對你來說是什麼」。對 AshPostgres 來說就是建資料庫、跑 migration、裝 Ash 需要的 Postgres function 跟 extension (installer 的 migration 就建了 citext)。

$ mix ash.setup
Getting extensions in current project...
Running setup for AshPostgres.DataLayer...
The database for DemoCms.Repo has already been created
[info] Migrations already up

installer 也改了專案的 alias, 所以 mix setupmix test 現在呼叫的是 ash.setup 而不是 ecto.setup:

setup: ["deps.get", "ash.setup", "assets.setup", "assets.build", "run priv/repo/seeds.exs"],
test: ["ash.setup --quiet", "test"],

Ecto 的 task 都還在。ash.* 是疊在上面的一層, 不是取代。

產生器

Phoenix + Ecto: mix phx.gen.schema 寫一個 schema module 跟一個 migration。mix phx.gen.context 寫一個 context module, 裡面是包著 schema 的函式。mix phx.gen.livemix phx.gen.html 兩個都做, 再加上 web 那層。

Ash: mix ash.gen.domainmix ash.gen.resource。domain 對應的是 context: 一群 resource 的集合。resource 對應的是 schema, 但它同時裝著原本放在 context 函式裡的東西。

mix ash.gen.resource DemoCms.Content.Post \
  --uuid-primary-key id \
  --attribute title:string:required:public,body:string:public \
  --default-actions read,create \
  --timestamps \
  --extend postgres

DemoCms.Content 本來不存在, 所以順便建好, 並把 resource 註冊進去:

defmodule DemoCms.Content do
  use Ash.Domain, otp_app: :demo_cms

  resources do
    resource DemoCms.Content.Post
  end
end

resource, lib/demo_cms/content/post.ex:

defmodule DemoCms.Content.Post do
  use Ash.Resource,
    otp_app: :demo_cms,
    domain: DemoCms.Content,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "posts"
    repo DemoCms.Repo
  end

  actions do
    defaults [:read, create: [:title, :body]]
  end

  attributes do
    uuid_primary_key :id

    attribute :title, :string do
      allow_nil? false
      public? true
    end

    attribute :body, :string do
      public? true
    end

    timestamps()
  end
end

attribute、一張表、兩個 action。每一行是什麼意思是接下來幾篇的主題, 今天知道這個檔案存在就夠了。產生器也把 domain 加進 config :demo_cms, ash_domains: [DemoCms.Content, DemoCms.Accounts], Ash 的工具就是靠這個清單找 domain, 不會去掃 lib/

另外兩組產生器, 之後會用到:

  • mix phx.gen.authmix igniter.install ash_authentication ash_authentication_phoenix --auth-strategy magic_link。建置那步已經跑過了, 它產生了 UserToken 這兩個 resource 跟路由。
  • mix phx.gen.livemix ash_phoenix.gen.live。概念一樣, 差別是它讀一個已存在的 resource, 而不是吃一串欄位清單。

Migration

Phoenix + Ecto: mix ecto.gen.migration add_posts 建一個空檔案, create table 我們自己寫。mix ecto.migrate 跑, mix ecto.rollback 退, mix ecto.migrations 看狀態。

Ash: mix ash.codegen add_posts 從 resource 寫出 migration。它把每個 resource 跟 priv/resource_snapshots/ 裡上一次的快照比對, 把差異寫出來:

$ mix ash.codegen create_posts
Getting extensions in current project...
Running codegen for AshPostgres.DataLayer...
Please always manually review the generated migrations for correctness.
* creating priv/repo/migrations/20260915164615_create_posts.exs
* creating priv/resource_snapshots/repo/posts/20260915164616.json
def up do
  create table(:posts, primary_key: false) do
    add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
    add :title, :text, null: false
    add :body, :text

    add :inserted_at, :utc_datetime_usec,
      null: false,
      default: fragment("(now() AT TIME ZONE 'utc')")

    add :updated_at, :utc_datetime_usec,
      null: false,
      default: fragment("(now() AT TIME ZONE 'utc')")
  end
end

allow_nil? false 變成了 null: false。這就是一個普通的 Ecto migration, 看過後如果需要的話可以改, 然後再執行:

mix ash.migrate      # 每個 data layer 的 ecto.migrate
mix ash.rollback     # ecto.rollback
mix ash.codegen --check   # resource 跟快照不一致就失敗, 給 CI 用

mix ecto.migrations 一樣看得到狀態:

  Status    Migration ID    Migration Name
--------------------------------------------------
  up        20260915164458  initialize_and_add_authentication_resources_and_add_magic_link_auth_extensions_1
  up        20260915164459  initialize_and_add_authentication_resources_and_add_magic_link_auth
  up        20260915164615  create_posts

從現在開始, 改一個 attribute 就是流程就變成:
改 resource
mix ash.codegen 名字
檢查檔案,
mix ash.migrate

依賴套件

Phoenix + Ecto: 在 mix.exs{:pkg, "~> x"}, mix deps.get, 然後照 README 手動加 config 跟 router 的那幾行。

Ash: mix igniter.install pkg。它加依賴、抓下來, 然後跑那個套件的 installer, 由 installer 幫你改 mix.exsconfig/*.exs、router 等等。建置那步就是這樣跑了七次。兩個相關的 task:

  • mix ash.extend DemoCms.Content.Post json_api 幫既有的 resource 或 domain 加一個 extension。
  • mix igniter.upgrademix deps.update 加上套件附的升級腳本。

常用的指令

mix phx.serveriex -S mixmix testmix format: 這些沒變。

mix format 現在多跑一個 Spark formatter plugin, 所以產生的 resource 使用 ash 風格的寫法不會有括號

mix phx.routes 也沒變, 而且看得到 auth installer 加了什麼:

GET     /                                DemoCmsWeb.PageController :home
GET     /auth/user/magic_link            DemoCms.Accounts.User.magic_link :accept
POST    /auth/user/magic_link/request    DemoCms.Accounts.User.magic_link :request
POST    /auth/user/magic_link            DemoCms.Accounts.User.magic_link :sign_in
GET     /sign-out                        AshAuthentication.Phoenix.SignOutLive :sign_out
GET     /sign-in                         AshAuthentication.Phoenix.SignInLive :sign_in
GET     /register                        AshAuthentication.Phoenix.SignInLive :register
GET     /magic_link/:token               AshAuthentication.Phoenix.MagicSignInLive :sign_in
GET     /admin/*route                    AshAdmin.PageLive :page
...

mix ash_authentication.phoenix.routes 只印 auth 相關的。

iex 裡, Ecto 的 Repo.insert(%Post{} |> Post.changeset(attrs)) 對應到 Ash 的 Ash.create(Post, attrs):

iex> Ash.create!(DemoCms.Content.Post, %{title: "Hello, Ash", body: "First post."})
%DemoCms.Content.Post{
  id: "882bbad6-760d-4728-9c58-ec34e23100c8",
  title: "Hello, Ash",
  body: "First post.",
  ...
}

iex> Ash.read!(DemoCms.Content.Post) |> Enum.map(& &1.title)
["Hello, Ash"]

iex> DemoCms.Repo.query!("select title from posts").rows
[["Hello, Ash"]]

資料就是普通的 Postgres 資料。Repo.insertRepo.all 也還能用, 不過現在對我們來說他們是底層
比較少直接使用

其他 Ash 提供的工具

  • mix ash_postgres.gen.resources 從既有的資料庫 schema 產生 resource
  • mix ash.generate_resource_diagrams 幫每個 domain 畫一張 Mermaid 的實體關係圖
  • mix ash.generate_policy_charts 把一個 resource 的授權 policy 畫成流程圖
  • mix ash.generate_livebook 幫每個 domain 產生一本 Livebook, 把 resource 文件化

上一篇
Ash 要解決的是什麼
系列文
Ash framework, Elixir 的商業邏輯框架2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言